SDF Textures - Sixth 3D

Table of Contents

<- Back to index

1. What SDF textures are

A regular texture stores coverage: each texel says "this much ink here". That is a photocopy of the glyph — resample it (magnify, minify, view at an angle) and the stored pixels blur or alias, because the information about where the edge is was thrown away when the glyph was rasterized.

A signed distance field (SDF) texture stores something smarter: per texel, the distance to the nearest edge — negative inside the ink, positive outside, zero exactly on the boundary. The rasterizer then re-derives coverage per screen pixel from this smooth field. The edge position survives resampling because the field around it is linear — bilinear interpolation of a linear ramp is exact.

SDF concept.svg

In Sixth 3D the mask is a grayscale field in texture.sdfMask:

  • 0 — deep inside the ink
  • 127.5 — exactly on the edge
  • 255 — far outside any glyph

The gradient spans only SPREAD_TEXELS = 2.0 texels around the edge — that narrow band is all the rasterizer needs.

Here is a real field, dumped straight from SdfGlyphCache (glyph "S", 16x32 texels, upscaled 12x with nearest so you can see the texels):

glyph-sdf-S.png

Dark inside the strokes, bright outside, and a smooth gray ramp exactly two texels wide around the contour.

2. Generating glyph fields

SdfGlyphCache generates each character's distance field once and caches it in a ConcurrentHashMap; stamping a glyph into a canvas is then just a block copy.

SDF glyph pipeline.svg

The steps:

  1. Rasterize the glyph with AWT at 4x the cell size (64x128 pixels) with anti-aliasing on, using Liberation Mono Bold (metric-compatible with Courier New, so the cell grid is unchanged). The font size is auto-shrunk until the widest glyph fits the scratch without clipping — a clipped glyph would corrupt the distance field at the cell edge.
  2. Distance transform: an exact Euclidean distance transform (Felzenszwalb & Huttenlocher, two separable 1-D passes over parabola envelopes) is run twice — once for distance to nearest ink pixel, once for distance to nearest background pixel.
  3. Sign, clamp, average: signed distance = dOut - dIn, clamped to +/-2 texels of spread, then the field (not coverage) is averaged down to the 16x32 cell resolution. Averaging the field preserves the edge position; averaging coverage would not.

The font choice matters: Courier's serifs and hairline strokes decay into unresolvable noise when the field is minified. A uniform-stroke bold sans-serif survives.

3. The rendering path

When texture.isSdf() is true (an sdfMask is attached), TexturedTriangle.paintSdf takes over. Three layers are involved:

Layer Contents Sampling
sdfMask glyph shapes (the field) bilinear
sdfForeground ink color, flat per cell nearest
primaryBitmap background color, per cell nearest

Per screen pixel:

  1. Sample the mask bilinearly (fixed-point) -> distance d.
  2. Convert to coverage: cov = (127.5 - d) * aaK + 128, clamped to [0, 256]. aaK scales the 2-texel gradient window to the current pixel footprint (see next section).
  3. Blend: pixel = bg * (1 - cov) + fg * cov.

Perspective-correct interpolation applies to SDF triangles exactly as it does to regular textured triangles — same affine-sufficiency test, same subdivided correction. See Perspective-correct textures; only the per-pixel sampling differs.

4. Minification without mipmaps

There is deliberately no mipmap chain for SDF layers. A distance field's edge gradient spans ~2 texels; a half-resolution mask melts the glyph edges. Worse, the two triangles of a rectangle cross mip thresholds at slightly different distances, producing a hard diagonal quality split and sudden blur steps while dollying (observed in practice).

Minification is instead handled analytically: the coverage window is widened by the screen-space pixel footprint, giving area-correct coverage straight from the primary field.

SDF minification.svg

The footprint is computed per axis from the screen-space UV gradients — text on an angled plane is minified mostly along one axis, and an isotropic average would blur the axis that still has resolution to spare. The coverage window follows the sharpest axis.

Area-correct coverage alone reads as a low-contrast gray haze, so two perceptual corrections (A/B-tuned on far + angled text) kick in under minification:

  • Sharpening (SDF_SHARPEN, default 2): narrows the coverage window below one pixel — kills the haze halo at the cost of slight shimmer.
  • Coverage gamma (< 1, automatic from the footprint): darkens stems like a small-size font rasterizer, keeping thin strokes present.

Real output, rendered headlessly through the Snapshot tool:

Magnified — edges re-derived at display resolution, razor sharp:

sdf-near.png

At moderate distance:

sdf-mid.png

Far away — small but clean, fading to gray instead of disintegrating into aliases (right: 4x nearest zoom of the center):

sdf-far.png

sdf-far-zoom.png

At an oblique angle — foreshortened along one axis, still sharp along the other:

sdf-angled.png

5. Using it

TextCanvas is the main entry point: a textured rectangle carrying a character grid in 3D space. World cell size 8x16 units, texture cell 16x32 texels (2 texels per world unit).

Transform location = new Transform(new Point3D(0, 0, 500));
TextCanvas canvas = new TextCanvas(location, "Hello, World!",
        Color.WHITE, Color.BLACK);
shapeCollection.addShape(canvas);

// blank canvas + cursor writing
TextCanvas blank = new TextCanvas(location, new TextPointer(10, 40),
        Color.GREEN, Color.BLACK);
blank.locate(0, 0);
blank.print("Line 1");
blank.locate(1, 0);
blank.print("Line 2");
blank.setForegroundColor(Color.RED);   // affects subsequent writes
blank.setTextColor(Color.CYAN);        // recolors existing ink only

Colors are per-cell: each putChar fills the cell's rectangle in the background and foreground layers, so one canvas can hold many colors.

ForwardOrientedTextBlock renders the same pipeline onto a billboard that always faces the camera — for labels that must stay readable from any angle:

ForwardOrientedTextBlock label = new ForwardOrientedTextBlock(
        new Point3D(0, -50, 300), 1.0, 2, "Hello, World!", Color.RED);
shapeCollection.addShape(label);

Real use in the demos: the life demo's help panel (life_demo/Main.java createHelpPanel()) and the axis labels in CoordinateSystemDemo.

6. Tuning knobs

JVM properties (A/B tuning knobs in TexturedTriangle):

Property Default Effect
e3d.sdf.gamma 0 (auto) fixed coverage gamma; auto derives from footprint
e3d.sdf.sharpen 2 coverage window narrowing; 1 = pixel-exact
e3d.sdf.debug false prints per-triangle footprints and path decisions to stderr

7. Limitations

  • Fixed cell grid: TextCanvas is monospace by construction (16x32 texel cells). Proportional fonts would need a different stamping scheme.
  • ASCII-oriented cache: SdfGlyphCache measures printable ASCII (33..126) when sizing the font; exotic glyphs may fit worse.
  • Under extreme minification text fades to gray by design — that is the correct physical answer (a sub-pixel glyph has no shape left), but it means distant labels are decorative, not readable.
  • Bandwidth under minification: sampling the primary field (no mip chain) costs more bandwidth per pixel. Text surfaces are small, so this is the right trade — do not attach SDF masks to huge surfaces.

Author: Svjatoslav Agejenko

Created: 2026-09-08 ti 00:46

Validate